Fix: Sonos Airplay mode
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 20 Nov 2024 17:39:15 +0000 (18:39 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 20 Nov 2024 17:39:15 +0000 (18:39 +0100)
music_assistant/providers/airplay/const.py
music_assistant/providers/airplay/helpers.py
music_assistant/providers/airplay/manifest.json
music_assistant/providers/airplay/provider.py
music_assistant/providers/sonos/manifest.json
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
requirements_all.txt

index ff636979b889c2b1c32f406486dc8f94ec8c7ea3..802456c0b89070bce42b226ee42178b25c7c8ca4 100644 (file)
@@ -30,14 +30,16 @@ AIRPLAY_PCM_FORMAT = AudioFormat(
     content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16
 )
 
-IGNORE_RAOP_SONOS_MODELS = (
+BROKEN_RAOP_MODELS = (
     # A recent fw update of newer gen Sonos speakers block RAOP (airplay 1) support,
     # basically rendering our airplay implementation useless on these devices.
     # This list contains the models that are known to have this issue.
     # Hopefully the issue won't spread to other models.
-    "Era 100",
-    "Era 300",
-    "Move 2",
-    "Roam 2",
-    "Arc Ultra",
+    ("Sonos", "Era 100"),
+    ("Sonos", "Era 300"),
+    ("Sonos", "Move 2"),
+    ("Sonos", "Roam 2"),
+    ("Sonos", "Arc Ultra"),
+    # Samsung has been repeatedly being reported as having issues with AirPlay 1/raop
+    ("Samsung", "*"),
 )
index fe8f51807ca3f0caae260feda0fde506eb093fb9..ddb6c63a83436cced0b58ce89c29767b02a59fed 100644 (file)
@@ -6,6 +6,8 @@ from typing import TYPE_CHECKING
 
 from zeroconf import IPVersion
 
+from music_assistant.providers.airplay.const import BROKEN_RAOP_MODELS
+
 if TYPE_CHECKING:
     from zeroconf.asyncio import AsyncServiceInfo
 
@@ -20,23 +22,49 @@ def convert_airplay_volume(value: float) -> int:
     return int(portion + normal_min)
 
 
-def get_model_from_am(am_property: str | None) -> tuple[str, str]:
-    """Return Manufacturer and Model name from mdns AM property."""
-    manufacturer = "Unknown"
-    model = "Generic Airplay device"
-    if not am_property:
+def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]:
+    """Return Manufacturer and Model name from mdns info."""
+    manufacturer = info.decoded_properties.get("manufacturer")
+    model = info.decoded_properties.get("model")
+    if manufacturer and model:
         return (manufacturer, model)
-    if isinstance(am_property, bytes):
-        am_property = am_property.decode("utf-8")
-    if am_property == "AudioAccessory5,1":
-        model = "HomePod"
-        manufacturer = "Apple"
-    elif "AppleTV" in am_property:
+    # try parse from am property
+    if am_property := info.decoded_properties.get("am"):
+        if isinstance(am_property, bytes):
+            am_property = am_property.decode("utf-8")
+        model = am_property
+
+    if not model:
+        model = "Unknown"
+
+    # parse apple model names
+    if model == "AudioAccessory6,1":
+        return ("Apple", "HomePod 2")
+    if model in ("AudioAccessory5,1", "AudioAccessorySingle5,1"):
+        return ("Apple", "HomePod Mini")
+    if model == "AppleTV1,1":
+        return ("Apple", "Apple TV Gen1")
+    if model == "AppleTV2,1":
+        return ("Apple", "Apple TV Gen2")
+    if model in ("AppleTV3,1", "AppleTV3,2"):
+        return ("Apple", "Apple TV Gen3")
+    if model == "AppleTV5,3":
+        return ("Apple", "Apple TV Gen4")
+    if model == "AppleTV6,2":
+        return ("Apple", "Apple TV 4K")
+    if model == "AppleTV11,1":
+        return ("Apple", "Apple TV 4K Gen2")
+    if model == "AppleTV14,1":
+        return ("Apple", "Apple TV 4K Gen3")
+    if "AirPort" in model:
+        return ("Apple", "AirPort Express")
+    if "AudioAccessory" in model:
+        return ("Apple", "HomePod")
+    if "AppleTV" in model:
         model = "Apple TV"
         manufacturer = "Apple"
-    else:
-        model = am_property
-    return (manufacturer, model)
+
+    return (manufacturer or "Airplay", model)
 
 
 def get_primary_ip_address(discovery_info: AsyncServiceInfo) -> str | None:
@@ -50,3 +78,11 @@ def get_primary_ip_address(discovery_info: AsyncServiceInfo) -> str | None:
             continue
         return address
     return None
+
+
+def is_broken_raop_model(manufacturer: str, model: str) -> bool:
+    """Check if a model is known to have broken RAOP support."""
+    for broken_manufacturer, broken_model in BROKEN_RAOP_MODELS:
+        if broken_manufacturer in (manufacturer, "*") and broken_model in (model, "*"):
+            return True
+    return False
index 3dbbbbb6f7cfd494891eb4df470609e578fc3f97..f465e59d266d2b7223597593f82ae6723b135b04 100644 (file)
@@ -3,15 +3,11 @@
   "domain": "airplay",
   "name": "Airplay",
   "description": "Support for players that support the Airplay protocol.",
-  "codeowners": [
-    "@music-assistant"
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "https://music-assistant.io/player-support/airplay/",
   "multi_instance": false,
   "builtin": false,
   "icon": "cast-variant",
-  "mdns_discovery": [
-    "_raop._tcp.local."
-  ]
+  "mdns_discovery": ["_raop._tcp.local."]
 }
index 39f722a359fbd5a0d8df809b4529d4714b03958b..b625ed8949bcf88ca02f1d413ab3eec73fb091cb 100644 (file)
@@ -52,9 +52,13 @@ from .const import (
     CONF_PASSWORD,
     CONF_READ_AHEAD_BUFFER,
     FALLBACK_VOLUME,
-    IGNORE_RAOP_SONOS_MODELS,
 )
-from .helpers import convert_airplay_volume, get_model_from_am, get_primary_ip_address
+from .helpers import (
+    convert_airplay_volume,
+    get_model_info,
+    get_primary_ip_address,
+    is_broken_raop_model,
+)
 from .player import AirPlayPlayer
 
 if TYPE_CHECKING:
@@ -113,6 +117,14 @@ PLAYER_CONFIG_ENTRIES = (
     create_sample_rates_config_entry(44100, 16, 44100, 16, True),
 )
 
+BROKEN_RAOP_WARN = ConfigEntry(
+    key="broken_raop",
+    type=ConfigEntryType.ALERT,
+    default_value=None,
+    required=False,
+    label="This player is known to have broken Airplay 1 (RAOP) support. "
+    "Playback may fail or simply be silent. There is no workaround for this issue at the moment.",
+)
 
 # TODO: Airplay provider
 # - Implement authentication for Apple TV
@@ -168,7 +180,13 @@ class AirplayProvider(PlayerProvider):
         self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
     ) -> None:
         """Handle MDNS service state callback."""
-        raw_id, display_name = name.split(".")[0].split("@", 1)
+        if "@" in name:
+            raw_id, display_name = name.split(".")[0].split("@", 1)
+        elif "deviceid" in info.decoded_properties:
+            raw_id = info.decoded_properties["deviceid"].replace(":", "")
+            display_name = info.name.split(".")[0]
+        else:
+            return
         player_id = f"ap{raw_id.lower()}"
         # handle removed player
         if state_change == ServiceStateChange.Removed:
@@ -219,6 +237,9 @@ class AirplayProvider(PlayerProvider):
     async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]:
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         base_entries = await super().get_player_config_entries(player_id)
+        if player := self.mass.players.get(player_id):
+            if is_broken_raop_model(player.device_info.manufacturer, player.device_info.model):
+                return (*base_entries, BROKEN_RAOP_WARN, *PLAYER_CONFIG_ENTRIES)
         return (*base_entries, *PLAYER_CONFIG_ENTRIES)
 
     async def cmd_stop(self, player_id: str) -> None:
@@ -450,9 +471,18 @@ class AirplayProvider(PlayerProvider):
         if address is None:
             return
         self.logger.debug("Discovered Airplay device %s on %s", display_name, address)
-        manufacturer, model = get_model_from_am(info.decoded_properties.get("am"))
 
-        default_enabled = not info.server.startswith("Sonos-")
+        # prefer airplay mdns info as it has more details
+        # fallback to raop info if airplay info is not available
+        airplay_info = AsyncServiceInfo(
+            "_airplay._tcp.local.", info.name.split("@")[-1].replace("_raop", "_airplay")
+        )
+        if await airplay_info.async_request(self.mass.aiozc.zeroconf, 3000):
+            manufacturer, model = get_model_info(airplay_info)
+        else:
+            manufacturer, model = get_model_info(info)
+
+        default_enabled = not is_broken_raop_model(manufacturer, model)
         if not self.mass.config.get_raw_player_config_value(player_id, "enabled", default_enabled):
             self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name)
             return
@@ -465,15 +495,11 @@ class AirplayProvider(PlayerProvider):
                 "Ignoring %s in discovery because it is not yet supported.", display_name
             )
             return
-        if model in IGNORE_RAOP_SONOS_MODELS:
-            # for now completely ignore the sonos models that have broken RAOP support
-            # its very much unlikely that this will ever be fixed by Sonos
-            # revisit this once/if we have support for airplay 2.
-            self.logger.info(
-                "Ignoring %s in discovery as it is a known Sonos model with broken RAOP support.",
-                display_name,
-            )
-            return
+
+        # append airplay to the default display name for generic (non-apple) devices
+        # this makes it easier for users to distinguish between airplay and non-airplay devices
+        if manufacturer.lower() != "apple" and "airplay" not in display_name.lower():
+            display_name += " (Airplay)"
 
         self._players[player_id] = AirPlayPlayer(self, player_id, info, address)
         if not (volume := await self.mass.cache.get(player_id, base_key=CACHE_KEY_PREV_VOLUME)):
index ba904cd38f6e6308295ab39332485bebf9e72431..28422b4d24aa892f0df09408fd0cb717dad652cb 100644 (file)
@@ -4,7 +4,7 @@
   "name": "SONOS",
   "description": "SONOS Player provider for Music Assistant.",
   "codeowners": ["@music-assistant"],
-  "requirements": ["aiosonos==0.1.6"],
+  "requirements": ["aiosonos==0.1.7"],
   "documentation": "https://music-assistant.io/player-support/sonos/",
   "multi_instance": false,
   "builtin": false,
index 4aee6abf31a9e44c55456615a8016abd249936b4..a19d3c4adaaf2ac083b80480be11fb6b5c2b745b 100644 (file)
@@ -79,20 +79,21 @@ class SonosPlayer:
         self.queue_version: str = shortuuid.random(8)
         self._on_cleanup_callbacks: list[Callable[[], None]] = []
 
-    def get_linked_airplay_player(
-        self, enabled_only: bool = True, active_only: bool = False
-    ) -> Player | None:
+    @property
+    def airplay_mode_enabled(self) -> bool:
+        """Return if airplay mode is enabled for the player."""
+        return self.mass.config.get_raw_player_config_value(
+            self.player_id, CONF_AIRPLAY_MODE, False
+        )
+
+    def get_linked_airplay_player(self, enabled_only: bool = True) -> Player | None:
         """Return the linked airplay player if available/enabled."""
-        if enabled_only and not self.mass.config.get_raw_player_config_value(
-            self.player_id, CONF_AIRPLAY_MODE
-        ):
+        if enabled_only and not self.airplay_mode_enabled:
             return None
         if not (airplay_player := self.mass.players.get(self.airplay_player_id)):
             return None
         if not airplay_player.available:
             return None
-        if active_only and not airplay_player.powered and not airplay_player.group_childs:
-            return None
         return airplay_player
 
     async def setup(self) -> None:
@@ -106,6 +107,8 @@ class SonosPlayer:
             supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
         if not self.client.player.has_fixed_volume:
             supported_features.add(PlayerFeature.VOLUME_SET)
+        if not self.get_linked_airplay_player(False):
+            supported_features.add(PlayerFeature.NEXT_PREVIOUS)
 
         # instantiate the MA player
         self.mass_player = mass_player = Player(
@@ -177,9 +180,7 @@ 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, True)
-        ) and airplay.state != PlayerState.IDLE:
+        if (airplay := self.get_linked_airplay_player(True)) and airplay.state != PlayerState.IDLE:
             # linked airplay player is active, redirect the command
             self.logger.debug("Redirecting STOP command to linked airplay player.")
             if player_provider := self.mass.get_provider(airplay.provider):
@@ -196,9 +197,7 @@ 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, True)
-        ) and airplay.state != PlayerState.IDLE:
+        if (airplay := self.get_linked_airplay_player(True)) and airplay.state != PlayerState.IDLE:
             # linked airplay player is active, redirect the command
             self.logger.debug("Redirecting PLAY command to linked airplay player.")
             if player_provider := self.mass.get_provider(airplay.provider):
@@ -211,9 +210,7 @@ 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, True)
-        ) and airplay.state != PlayerState.IDLE:
+        if (airplay := self.get_linked_airplay_player(True)) and airplay.state != PlayerState.IDLE:
             # linked airplay player is active, redirect the command
             self.logger.debug("Redirecting PAUSE command to linked airplay player.")
             if player_provider := self.mass.get_provider(airplay.provider):
@@ -267,28 +264,6 @@ class SonosPlayer:
             self.mass_player.synced_to = active_group.coordinator_id
             self.mass_player.active_source = active_group.coordinator_id
 
-        if airplay := self.get_linked_airplay_player(True, True):
-            # 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
-            # mark 'next_previous' feature as unsupported when airplay mode is active
-            if PlayerFeature.NEXT_PREVIOUS in self.mass_player.supported_features:
-                self.mass_player.supported_features = (
-                    x
-                    for x in self.mass_player.supported_features
-                    if x != PlayerFeature.NEXT_PREVIOUS
-                )
-            return
-        # ensure 'next_previous' feature is supported when airplay mode is not active
-        if PlayerFeature.NEXT_PREVIOUS not in self.mass_player.supported_features:
-            self.mass_player.supported_features = (
-                *self.mass_player.supported_features,
-                PlayerFeature.NEXT_PREVIOUS,
-            )
-
         # map playback state
         self.mass_player.state = PLAYBACK_STATE_MAP[active_group.playback_state]
         self.mass_player.elapsed_time = active_group.position
@@ -301,12 +276,21 @@ class SonosPlayer:
             self.mass_player.active_source = SOURCE_LINE_IN
         elif container_type == ContainerType.AIRPLAY:
             # check if the MA airplay player is active
-            airplay_player = self.mass.players.get(self.airplay_player_id)
+            airplay_player = self.get_linked_airplay_player(False)
             if airplay_player and airplay_player.state in (
                 PlayerState.PLAYING,
                 PlayerState.PAUSED,
             ):
+                self.mass_player.state = airplay_player.state
+                self.mass_player.powered = True
                 self.mass_player.active_source = airplay_player.active_source
+                self.mass_player.elapsed_time = airplay_player.elapsed_time
+                self.mass_player.elapsed_time_last_updated = (
+                    airplay_player.elapsed_time_last_updated
+                )
+                self.mass_player.current_media = airplay_player.current_media
+                # return early as we dont need further info
+                return
             else:
                 self.mass_player.active_source = SOURCE_AIRPLAY
         elif container_type == ContainerType.STATION:
index fa83e0e38111db110ab9ff5f7de4c7b9b8fb581a..fed714df91f9184c7320ec672bb24647a1349674 100644 (file)
@@ -144,7 +144,7 @@ class SonosPlayerProvider(PlayerProvider):
             ConfigEntry(
                 key=CONF_AIRPLAY_MODE,
                 type=ConfigEntryType.BOOLEAN,
-                label="Enable Airplay mode (experimental)",
+                label="Enable Airplay mode",
                 description="Almost all newer Sonos speakers have Airplay support. "
                 "If you have the Airplay provider enabled in Music Assistant, "
                 "your Sonos speaker will also be detected as a Airplay speaker, meaning "
@@ -153,10 +153,8 @@ class SonosPlayerProvider(PlayerProvider):
                 "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"
-                "NOTE: You need to have the Airplay provider enabled. "
-                "Also make sure that the Airplay version of this player is enabled. \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.",
+                "NOTE: You need to have the Airplay provider enabled as well as "
+                "the Airplay version of this player.",
                 required=False,
                 default_value=False,
                 depends_on="airplay_detected",
@@ -235,23 +233,24 @@ class SonosPlayerProvider(PlayerProvider):
             raise PlayerCommandFailed(msg)
         # for now always reset the active session
         sonos_player.client.player.group.active_session_id = None
-        if airplay := sonos_player.get_linked_airplay_player(True, True):
-            # linked airplay player is active, redirect the command
+        if airplay := sonos_player.get_linked_airplay_player(True):
+            # airplay mode is enabled, 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 []
-            )
+            group_childs = [
+                x for x in sonos_player.client.player.group.player_ids if x != player_id
+            ]
             if group_childs:
                 await self.mass.players.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)
+                # ensure master player is first in the list
+                group_childs = [sonos_player.player_id, *group_childs]
+                await asyncio.sleep(5)
+                await sonos_player.client.player.group.set_group_members(group_childs)
             return
 
         if media.queue_id and media.queue_id.startswith("ugp_"):
index 5863d8844d0d5352cdc9a6dd02891a5e2de6fd54..ca5f5ab1a8e9cf800bfa700b7a47cefe505b6f5a 100644 (file)
@@ -7,7 +7,7 @@ aiohttp==3.10.10
 aiojellyfin==0.10.1
 aiorun==2024.8.1
 aioslimproto==3.1.0
-aiosonos==0.1.6
+aiosonos==0.1.7
 aiosqlite==0.20.0
 async-upnp-client==0.41.0
 bidict==0.23.1