Feat: Implement source control on Sonos provider
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 31 Jan 2025 18:00:18 +0000 (19:00 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 31 Jan 2025 18:00:18 +0000 (19:00 +0100)
Implement source control/selection on Sonos provider (as reference)

music_assistant/controllers/players.py
music_assistant/providers/sonos/const.py
music_assistant/providers/sonos/manifest.json
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
requirements_all.txt

index f0fec78cd0dd3c569ed5dce51102fb9e45f985dc..0beeeb03ff950158f8c953d83d17293ca1a3fb9b 100644 (file)
@@ -689,17 +689,7 @@ class PlayerController(CoreController):
             media=media,
         )
 
-    async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
-        """Handle enqueuing of a next media item on the player."""
-        player = self.get(player_id, raise_unavailable=True)
-        if PlayerFeature.ENQUEUE not in player.supported_features:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support enqueueing"
-            )
-        player_prov = self.mass.get_provider(player.provider)
-        async with self._player_throttlers[player_id]:
-            await player_prov.enqueue_next_media(player_id=player_id, media=media)
-
+    @api_command("players/cmd/select_source")
     async def select_source(self, player_id: str, source: str) -> None:
         """
         Handle SELECT SOURCE command on given player.
@@ -722,6 +712,17 @@ class PlayerController(CoreController):
         provider = self.mass.get_provider(player.provider)
         await provider.select_source(player_id, source)
 
+    async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+        """Handle enqueuing of a next media item on the player."""
+        player = self.get(player_id, raise_unavailable=True)
+        if PlayerFeature.ENQUEUE not in player.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support enqueueing"
+            )
+        player_prov = self.mass.get_provider(player.provider)
+        async with self._player_throttlers[player_id]:
+            await player_prov.enqueue_next_media(player_id=player_id, media=media)
+
     @api_command("players/cmd/group")
     @handle_player_command
     async def cmd_group(self, player_id: str, target_player: str) -> None:
index b541cd16e36ec00125f8664552bdeadec339bad6..98618b4bec95f95fd9424c1d12810368ec9efca9 100644 (file)
@@ -4,6 +4,7 @@ from __future__ import annotations
 
 from aiosonos.api.models import PlayBackState as SonosPlayBackState
 from music_assistant_models.enums import PlayerFeature, PlayerState
+from music_assistant_models.player import PlayerSource
 
 PLAYBACK_STATE_MAP = {
     SonosPlayBackState.PLAYBACK_STATE_BUFFERING: PlayerState.PLAYING,
@@ -16,12 +17,59 @@ PLAYER_FEATURES_BASE = {
     PlayerFeature.SET_MEMBERS,
     PlayerFeature.PAUSE,
     PlayerFeature.ENQUEUE,
+    PlayerFeature.NEXT_PREVIOUS,
+    PlayerFeature.SEEK,
+    PlayerFeature.SELECT_SOURCE,
 }
 
 SOURCE_LINE_IN = "line_in"
 SOURCE_AIRPLAY = "airplay"
 SOURCE_SPOTIFY = "spotify"
 SOURCE_UNKNOWN = "unknown"
+SOURCE_TV = "tv"
 SOURCE_RADIO = "radio"
 
 CONF_AIRPLAY_MODE = "airplay_mode"
+
+PLAYER_SOURCE_MAP = {
+    SOURCE_LINE_IN: PlayerSource(
+        id=SOURCE_LINE_IN,
+        name="Line-in",
+        passive=False,
+        can_play_pause=False,
+        can_next_previous=False,
+        can_seek=False,
+    ),
+    SOURCE_TV: PlayerSource(
+        id=SOURCE_TV,
+        name="TV",
+        passive=False,
+        can_play_pause=False,
+        can_next_previous=False,
+        can_seek=False,
+    ),
+    SOURCE_AIRPLAY: PlayerSource(
+        id=SOURCE_AIRPLAY,
+        name="Spotify",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=True,
+        can_seek=True,
+    ),
+    SOURCE_SPOTIFY: PlayerSource(
+        id=SOURCE_SPOTIFY,
+        name="Spotify",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=True,
+        can_seek=True,
+    ),
+    SOURCE_RADIO: PlayerSource(
+        id=SOURCE_RADIO,
+        name="Spotify",
+        passive=True,
+        can_play_pause=True,
+        can_next_previous=True,
+        can_seek=True,
+    ),
+}
index 28422b4d24aa892f0df09408fd0cb717dad652cb..11ee50a34cacd0849f733ae3a2c2442352c62fac 100644 (file)
@@ -4,7 +4,7 @@
   "name": "SONOS",
   "description": "SONOS Player provider for Music Assistant.",
   "codeowners": ["@music-assistant"],
-  "requirements": ["aiosonos==0.1.7"],
+  "requirements": ["aiosonos==0.1.8"],
   "documentation": "https://music-assistant.io/player-support/sonos/",
   "multi_instance": false,
   "builtin": false,
index 568a623101bb1454844e0f69c9367d9278105173..5eca277dc82acc0e94c5dcf4de9614a3a572eee4 100644 (file)
@@ -36,10 +36,12 @@ from .const import (
     CONF_AIRPLAY_MODE,
     PLAYBACK_STATE_MAP,
     PLAYER_FEATURES_BASE,
+    PLAYER_SOURCE_MAP,
     SOURCE_AIRPLAY,
     SOURCE_LINE_IN,
     SOURCE_RADIO,
     SOURCE_SPOTIFY,
+    SOURCE_TV,
 )
 
 if TYPE_CHECKING:
@@ -140,6 +142,13 @@ class SonosPlayer:
             # but for now we assume we only have one
             can_group_with={self.prov.lookup_key},
         )
+        if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]:
+            mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN])
+        if SonosCapability.HT_PLAYBACK in self.discovery_info["device"]["capabilities"]:
+            mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_TV])
+        if SonosCapability.AIRPLAY in self.discovery_info["device"]["capabilities"]:
+            mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_AIRPLAY])
+
         self.update_attributes()
         await self.mass.players.register_or_update(mass_player)
 
@@ -201,11 +210,7 @@ class SonosPlayer:
             if player_provider := self.mass.get_provider(airplay.provider):
                 await player_provider.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
+        await self.client.player.group.stop()
 
     async def cmd_play(self) -> None:
         """Send PLAY command to given player."""
@@ -231,8 +236,21 @@ class SonosPlayer:
             if player_provider := self.mass.get_provider(airplay.provider):
                 await player_provider.cmd_pause(airplay.player_id)
             return
+        if not self.client.player.group.playback_actions.can_pause:
+            await self.cmd_stop()
+            return
         await self.client.player.group.pause()
 
+    async def cmd_seek(self, position: int) -> None:
+        """Handle SEEK command for given player.
+
+        - position: position in seconds to seek to in the current playing item.
+        """
+        if self.client.player.is_passive:
+            self.logger.debug("Ignore STOP command: Player is synced to another player.")
+            return
+        await self.client.player.group.seek(position)
+
     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)
@@ -245,6 +263,16 @@ class SonosPlayer:
         """Send VOLUME MUTE command to given player."""
         await self.client.player.set_volume(muted=muted)
 
+    async def select_source(self, source: str) -> None:
+        """Handle SELECT SOURCE command on given player."""
+        if source == SOURCE_LINE_IN:
+            await self.client.player.group.load_line_in(play_on_completion=True)
+        elif source == SOURCE_TV:
+            await self.client.player.load_home_theater_playback()
+        else:
+            # unsupported source - try to clear the queue/player
+            await self.cmd_stop()
+
     def update_attributes(self) -> None:  # noqa: PLR0915
         """Update the player attributes."""
         if not self.mass_player:
@@ -303,6 +331,8 @@ class SonosPlayer:
         container = active_group.playback_metadata.get("container")
         if container_type == ContainerType.LINEIN:
             self.mass_player.active_source = SOURCE_LINE_IN
+        elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF):
+            self.mass_player.active_source = SOURCE_TV
         elif container_type == ContainerType.AIRPLAY:
             # check if the MA airplay player is active
             if airplay_player and airplay_player.state in (
@@ -322,8 +352,14 @@ class SonosPlayer:
                 self.mass_player.active_source = SOURCE_AIRPLAY
         elif container_type == ContainerType.STATION:
             self.mass_player.active_source = SOURCE_RADIO
+            # add radio to source list if not yet there
+            if SOURCE_RADIO not in [x.id for x in self.mass_player.source_list]:
+                self.mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_RADIO])
         elif active_service == MusicService.SPOTIFY:
             self.mass_player.active_source = SOURCE_SPOTIFY
+            # add spotify to source list if not yet there
+            if SOURCE_SPOTIFY not in [x.id for x in self.mass_player.source_list]:
+                self.mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY])
         elif active_service == MusicService.MUSIC_ASSISTANT:
             if self.client.player.is_coordinator:
                 self.mass_player.active_source = self.mass_player.player_id
@@ -331,18 +367,18 @@ class SonosPlayer:
                 self.mass_player.active_source = object_id.split(":")[-1]
             else:
                 self.mass_player.active_source = None
-        else:
-            # its playing some service we did not yet map
+        # its playing some service we did not yet map
+        elif container and container.get("service", {}).get("name"):
+            self.mass_player.active_source = container["service"]["name"]
+        elif container and container.get("name"):
+            self.mass_player.active_source = container["name"]
+        elif active_service:
             self.mass_player.active_source = active_service
-
-        # sonos has this weirdness that it maps idle to paused
-        # which is annoying to figure out if we want to resume or let
-        # MA back in control again. So for now, we just map it to idle here.
-        if (
-            self.mass_player.state == PlayerState.PAUSED
-            and active_service != MusicService.MUSIC_ASSISTANT
-        ):
-            self.mass_player.state = PlayerState.IDLE
+        elif container_type:
+            self.mass_player.active_source = container_type
+        else:
+            # the player has nothing loaded at all (empty queue and no service active)
+            self.mass_player.active_source = None
 
         # parse current media
         self.mass_player.elapsed_time = self.client.player.group.position
index 435c2375db22af9fc81d8f6bc2da431530fb041d..8651bd8f47efefdb0e2234aaa33c7ae809d1a3ba 100644 (file)
@@ -225,6 +225,15 @@ class SonosPlayerProvider(PlayerProvider):
         if sonos_player := self.sonos_players[player_id]:
             await sonos_player.cmd_pause()
 
+    async def cmd_seek(self, player_id: str, position: int) -> None:
+        """Handle SEEK command for given player.
+
+        - player_id: player_id of the player to handle the command.
+        - position: position in seconds to seek to in the current playing item.
+        """
+        if sonos_player := self.sonos_players[player_id]:
+            await sonos_player.cmd_seek(position)
+
     async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
         """Send VOLUME_SET command to given player."""
         if sonos_player := self.sonos_players[player_id]:
@@ -377,6 +386,11 @@ class SonosPlayerProvider(PlayerProvider):
         duration = media_info.duration or 10
         await asyncio.sleep(duration)
 
+    async def select_source(self, player_id: str, source: str) -> None:
+        """Handle SELECT SOURCE command on given player."""
+        if sonos_player := self.sonos_players[player_id]:
+            await sonos_player.select_source(source)
+
     async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None:
         """Handle setup of a new player that is discovered using mdns."""
         assert player_id not in self.sonos_players
index d8ddea25542029aef82bcb8706147fc7adf807b4..e23e3363ba0b88bfe315e593fd1e3dffef5f530b 100644 (file)
@@ -7,7 +7,7 @@ aiohttp==3.11.11
 aiojellyfin==0.14.1
 aiorun==2025.1.1
 aioslimproto==3.1.0
-aiosonos==0.1.7
+aiosonos==0.1.8
 aiosqlite==0.20.0
 async-upnp-client==0.42.0
 audible==0.10.0