From 2a9021764ef830fb0bbebc25f3d79a7af5959ae9 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 20 Nov 2024 18:39:15 +0100 Subject: [PATCH] Fix: Sonos Airplay mode --- music_assistant/providers/airplay/const.py | 14 ++-- music_assistant/providers/airplay/helpers.py | 64 +++++++++++++++---- .../providers/airplay/manifest.json | 8 +-- music_assistant/providers/airplay/provider.py | 54 ++++++++++++---- music_assistant/providers/sonos/manifest.json | 2 +- music_assistant/providers/sonos/player.py | 64 +++++++------------ music_assistant/providers/sonos/provider.py | 25 ++++---- requirements_all.txt | 2 +- 8 files changed, 138 insertions(+), 95 deletions(-) diff --git a/music_assistant/providers/airplay/const.py b/music_assistant/providers/airplay/const.py index ff636979..802456c0 100644 --- a/music_assistant/providers/airplay/const.py +++ b/music_assistant/providers/airplay/const.py @@ -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", "*"), ) diff --git a/music_assistant/providers/airplay/helpers.py b/music_assistant/providers/airplay/helpers.py index fe8f5180..ddb6c63a 100644 --- a/music_assistant/providers/airplay/helpers.py +++ b/music_assistant/providers/airplay/helpers.py @@ -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 diff --git a/music_assistant/providers/airplay/manifest.json b/music_assistant/providers/airplay/manifest.json index 3dbbbbb6..f465e59d 100644 --- a/music_assistant/providers/airplay/manifest.json +++ b/music_assistant/providers/airplay/manifest.json @@ -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."] } diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 39f722a3..b625ed89 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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)): diff --git a/music_assistant/providers/sonos/manifest.json b/music_assistant/providers/sonos/manifest.json index ba904cd3..28422b4d 100644 --- a/music_assistant/providers/sonos/manifest.json +++ b/music_assistant/providers/sonos/manifest.json @@ -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, diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 4aee6abf..a19d3c4a 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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: diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index fa83e0e3..fed714df 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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_"): diff --git a/requirements_all.txt b/requirements_all.txt index 5863d884..ca5f5ab1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 -- 2.34.1