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", "*"),
)
from zeroconf import IPVersion
+from music_assistant.providers.airplay.const import BROKEN_RAOP_MODELS
+
if TYPE_CHECKING:
from zeroconf.asyncio import AsyncServiceInfo
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:
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
"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."]
}
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:
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
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:
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:
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
"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)):
"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,
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:
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(
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):
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):
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):
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
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:
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 "
"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",
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_"):
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